iT邦幫忙

2026 iThome 鐵人賽

0
Software Development

Kotlin Lambda 從零開始系列 第 35

Kotlin Lambda 從零開始 Day 35:Contract — 讓編譯器更聰明

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會講清楚 kotlin.contractscallsInPlacereturns implies 兩種效果,理解為什麼 stdlib 的 letrunrequirecheckcheckNotNull 都需要 contract

Kotlin ↔ C# 對照表

Kotlin C# 備註
kotlin.contracts System.Diagnostics.Contracts(Code Contracts) 名字像而已,兩者範圍差很多,見下方說明
callsInPlace(block, EXACTLY_ONCE) 無對應 C# 不支援,見下方說明
returns() implies condition 無對應 C# 用 [NotNullWhen] 等 attribute

這裡要澄清一個常見誤解:不是 C# 的 compiler 對 Lambda「比較聰明、不需要宣告」,而是 C# 根本不支援這種分析

C# 的 definite assignment 分析不會穿透 lambda 或 delegate,你在 lambda 內賦值一個變數,lambda 外面讀取它,照樣會吃到 CS0165 編譯錯誤,只有改用 local function 並實際呼叫它,賦值狀態才會流回外層

Kotlin 的 callsInPlace 補的正是這個洞:因為 Lambda 可能被延遲執行、多次執行或不執行,compiler 需要 contract 提供的保證,才能做 smart cast 和初始化檢查

什麼是 contract

contract 是你跟 compiler 之間的「約定」。你告訴 compiler「我這個函式裡的 Lambda 一定會被執行一次」,或「我這個函式 return 了就表示某個條件成立」。compiler 拿到這些資訊後,可以做更聰明的靜態分析

目前 contract 還在實驗階段(@ExperimentalContracts)。使用時需要 @OptIn

callsInPlace 效果

問題:沒有 contract 的困境

fun <T, R> T.myLetNoContract(block: (T) -> R): R {
    return block(this)
}

val x: String
"hello".myLetNoContract { x = it }  // 編譯錯誤:captured values cannot be initialized because of possible reassignments
println(x)

compiler 不知道 block 到底會不會被執行。也許 myLetNoContract 根本沒呼叫 block?也許呼叫了兩次?如果沒呼叫,x 就沒被初始化;如果呼叫兩次,val x 就被賦值兩次。compiler 不敢賭,所以第一個編譯錯誤其實發生在 lambda 內的 x = it,一個 captured 的 val 在 Lambda 裡禁止賦值。連帶後面的 println(x) 也會抱怨 x 可能沒初始化

InvocationKind 的四種選項

callsInPlace 的第二個參數描述 Lambda 會被呼叫幾次

Kind 意思 適用的函式長什麼樣
EXACTLY_ONCE 一定執行一次 letrunapplyalso
AT_LEAST_ONCE 至少執行一次 內部至少跑一輪才可能結束的函式,例如 retryUntil(block)
AT_MOST_ONCE 最多執行一次(可能不執行) 有條件才呼叫 block 的函式,例如 runIfEnabled(block)
UNKNOWN 不確定 預設值,等於沒給 compiler 額外資訊

四種都是描述「你這個函式會怎麼呼叫傳進來的 Lambda」。stdlib 的五大 scope function 都用 EXACTLY_ONCE,因為它們保證 Lambda 一定會被執行正好一次;後面兩種在 stdlib 裡很少見,通常出現在自己寫的 API 上

TDD 實作 myLetWithContract 與 myRunWithContract

Red:先寫測試

我們想要的行為是:呼叫這兩個函式之後,Lambda 裡賦值的 val 能被 compiler 認定為「已初始化」

@Test
fun `letWithContract 讓 val 能在 Lambda 內初始化`() {
    val x: Int
    10.myLetWithContract { x = it + 5 }
    assertEquals(15, x)
}

@Test
fun `runWithContract 讓 val 能在 Lambda 內初始化`() {
    val x: String
    "hello".myRunWithContract { x = uppercase() }
    assertEquals("HELLO", x)
}

@Test
fun `letWithContract 把 receiver 當參數傳進 block`() {
    val result = "abc".myLetWithContract { it.length }
    assertEquals(3, result)
}

這三個測試在沒有 contract 的版本上連編譯都過不了,因為 val x 在 Lambda 內賦值會被擋下。這裡不用測 null 或例外,這兩個函式只是把 Lambda 跑一次,本身不拋例外,所以測試聚焦在「val 初始化」與「receiver 傳遞」兩個行為

Green:最小實作

@OptIn(ExperimentalContracts::class)
inline fun <T, R> T.myLetWithContract(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

@OptIn(ExperimentalContracts::class)
inline fun <T, R> T.myRunWithContract(block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

callsInPlace(block, EXACTLY_ONCE) 告訴 compiler block 一定會被執行正好一次。compiler 拿到這個保證後,就能確認 val x 會被初始化一次,前面的測試也就能通過編譯了

兩個函式只差 Lambda 型別:myLetWithContract(T) -> R,把 this 當參數傳入;myRunWithContractT.() -> R,所以 Lambda 裡可以直接寫 uppercase()。與 day 32 相比,這次多了 contract 宣告

Refactor:往 stdlib 的寫法靠近

stdlib 的 letrun 也使用這種 contract。對照 day 32 原本已標成 inlinemyLetmyRun,這次新增的是 contract 區塊與 @OptIn(ExperimentalContracts::class)

踩坑:忘了 import InvocationKind,錯誤訊息會指錯地方

Kotlin 的 default import 是一份固定列舉的清單:kotlin.*kotlin.annotation.*kotlin.collections.*kotlin.comparisons.*kotlin.io.*kotlin.ranges.*kotlin.sequences.*kotlin.text.*,JVM 上再加 java.lang.*kotlin.jvm.*kotlin.contracts 不在裡面,所以 InvocationKind 要自己 import。kotlin.math 也一樣,用 PIsqrt 同樣得補一行

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind  // ← 這行最容易漏掉
import kotlin.contracts.contract

漏掉的話,編譯會噴出這幾個錯誤

myLamdbaTest.kt:4037:9 Error in contract description: '<Unresolved name: InvocationKind>#.<Unresolved name: EXACTLY_ONCE>#' is not a valid invocation kind.
myLamdbaTest.kt:4037:29 Unresolved reference 'InvocationKind'.
myLamdbaTest.kt:2609:32 'val' cannot be reassigned.
myLamdbaTest.kt:2610:26 Variable 'x' must be initialized.

contract { } 裡的內容會先交給 compiler 的 contract 解析器,名稱解析失敗的結果被包成 contract 的錯誤訊息丟出來,排在真正的 Unresolved reference 前面。最上面那個最醒目,看起來像 contract 語法寫錯,實際要處理的是第二個。後面兩個 val 相關的錯誤則是連鎖反應,contract 沒生效,前面測試裡的 val x 自然就無法在 Lambda 內初始化

IntelliJ 貼上這段程式碼時,打字當下的 on-the-fly auto import 不一定會補上這行,把游標移到 InvocationKind 上按 Alt + Enter 比較保險

callsInPlace 反而不用 import,它是 ContractBuilder 的 member function,在 contract { } 的 receiver scope 裡就解析得到

returns implies 效果

問題:smart cast 過不了

fun customRequire(value: Boolean) {
    if (!value) throw IllegalArgumentException()
}

val input: Any = "hello"
customRequire(input is String)
// input.length  ← 編譯錯誤,compiler 不知道 input 一定是 String

compiler 看到 customRequire 回傳了,不代表它知道 input is String 是 true。也許 customRequire 裡面做了別的事情,條件不一定成立。要把這個保證告訴 compiler,就要靠 returns() implies

TDD 實作 myRequire、myCheck 與 myCheckNotNull

stdlib 的 requirecheckcheckNotNull 都靠 returns() implies 撐起 smart cast。三者差別只在拋的例外型別:requireIllegalArgumentException(參數錯)、checkIllegalStateException(狀態錯)、checkNotNull 在 null 時拋 IllegalStateException 並回傳非 null 值。我們一次手刻這三個

Red:先寫測試

@Test
fun `myRequire 條件為 true 時正常通過`() {
    myRequire(true) { "should not throw" }
}

@Test
fun `myRequire 條件為 false 時拋 IllegalArgumentException`() {
    assertThrows(IllegalArgumentException::class.java) {
        myRequire(false) { "value must be positive" }
    }
}

@Test
fun `myRequire 之後能 smart cast`() {
    val value: Any = "hello"
    myRequire(value is String) { "must be String" }
    assertEquals(5, value.length)  // smart cast 成功
}

@Test
fun `myCheck 條件為 true 時正常通過`() {
    myCheck(true) { "should not throw" }
}

@Test
fun `myCheck 條件為 false 時拋 IllegalStateException`() {
    assertThrows(IllegalStateException::class.java) {
        myCheck(false) { "state is invalid" }
    }
}

@Test
fun `myCheck 之後能 smart cast`() {
    val value: Any = 42
    myCheck(value is Int) { "must be Int" }
    assertEquals(43, value + 1)  // smart cast 成功
}

@Test
fun `myCheckNotNull 非 null 時回傳值並 smart cast`() {
    val name: String? = "Kotlin"
    val result = myCheckNotNull(name) { "name is null" }
    assertEquals(6, result.length)  // 回傳值非 null
    assertEquals(6, name.length)    // 原變數也 smart cast 成 String
}

@Test
fun `myCheckNotNull 為 null 時拋 IllegalStateException`() {
    val name: String? = null
    assertThrows(IllegalStateException::class.java) {
        myCheckNotNull(name) { "name is null" }
    }
}

myRequiremyCheck 各三個測試:happy path、拋例外、smart cast。myCheckNotNull 把 happy path 跟 smart cast 併成一個,因為它的回傳值本身就是非 null 的證明,再加一個 null 時拋例外

Green:最小實作

@OptIn(ExperimentalContracts::class)
inline fun myRequire(value: Boolean, lazyMessage: () -> String) {
    contract {
        returns() implies value
    }
    if (!value) {
        throw IllegalArgumentException(lazyMessage())
    }
}

@OptIn(ExperimentalContracts::class)
inline fun myCheck(value: Boolean, lazyMessage: () -> String) {
    contract {
        returns() implies value
    }
    if (!value) {
        throw IllegalStateException(lazyMessage())
    }
}

@OptIn(ExperimentalContracts::class)
inline fun <T : Any> myCheckNotNull(value: T?, lazyMessage: () -> String): T {
    contract {
        returns() implies (value != null)
    }
    if (value == null) {
        throw IllegalStateException(lazyMessage())
    }
    return value
}

returns() implies value 告訴 compiler:如果這個函式正常 return 了(沒有拋例外),那 value 一定是 true。所以下面這段就能 smart cast

val input: Any = "hello"
myRequire(input is String) { "must be String" }
input.length  // 現在可以 smart cast 了

myCheckNotNullreturns() implies (value != null) 更進一步:函式回傳之後,compiler 知道傳進去的 value 一定不是 null,連回傳值本身也是非 null 型別 T

Refactor:往 stdlib 的寫法靠近

三個函式已經跟 stdlib 同形,contract 區塊與例外型別都對得上,不需要再改

唯一要提的是 stdlib 把 lazyMessage 的型別寫成 () -> Any(而非我們的 () -> String),這樣訊息可以是任何物件、最後再 toString(),要更貼近 stdlib 可以調整,但對我們示範 contract 的目的沒影響

與 stdlib 原始碼比較

原始碼位置:kotlin.contractsContractBuilder.ktkotlinStandard.kt

stdlib 裡面大量使用 contract。對照幾個

// require
public inline fun require(value: Boolean, lazyMessage: () -> Any): Unit {
    contract {
        returns() implies value
    }
    if (!value) {
        val message = lazyMessage()
        throw IllegalArgumentException(message.toString())
    }
}

// let
public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

day 32 手刻的 scope function 跟 stdlib 唯一的差別就是 contract,加上 contract 之後,let 裡面初始化 val 才能通過編譯

contract 的限制

  1. 一般用法仍以 top-level 函式為主,而且 contract { } 必須是函式體的第一個 statement。Kotlin 2.2.20 起可透過 -Xallow-contracts-on-more-functions,實驗性地在 property accessor 與部分 operator function 使用 contract;這不代表任意 member function 都已支援。contract 本身不限定 inline,但 scope function 這類要讓 Lambda 內的賦值參與外層 definite assignment 分析,通常會搭配 inline
  2. 目前是實驗性 API,未來可能改動
  3. compiler 信任 contract 的宣告,不會驗證你有沒有說謊。如果你宣告 EXACTLY_ONCE 但實際呼叫了兩次,compiler 不會出現錯誤,但程式行為可能出問題
  4. implies 的右邊只能是簡單的布林表達式(valuevalue != nullvalue is Type)

第三點要注意,contract 是一種「榮譽制度」,你得自己保證宣告跟實作一致

C# 走了哪條路?

對照表把 kotlin.contracts 對到 System.Diagnostics.Contracts,但這只是名字像,兩者的範圍差非常多,這裡展開講一下

Code Contracts 是完整的 design by contract

Microsoft Research 在 2008 年推出 Code Contracts,設計哲學主要是借鏡 Eiffel 的 design by contract

  • 前置條件Contract.Requires(x > 0 && list.Count < 100),任意布林表達式都寫得出來
  • 後置條件Contract.Ensures(Contract.Result<int>() >= 0) 描述回傳值,Contract.OldValue<T>(e) 拿方法進入前的舊值來比對,EnsuresOnThrow<T> 描述拋特定例外時該成立什麼
  • 物件不變量:用 [ContractInvariantMethod] 標一個方法,裡面寫 Contract.Invariant(this.x > this.y),每個 public 方法結束時都會檢查
  • 量詞Contract.ForAllContract.Exists 描述整個集合要滿足的性質

配套工具是 ccrewrite(bytecode rewriter,把契約織成 runtime 檢查)和 cccheck(靜態驗證器,用 SMT 求解在編譯期證明契約成不成立),還能從契約產生文件

它死掉了,而且沒有等價的接班人

Microsoft 的 docs 現在寫得很直接

Code contracts aren't supported in .NET 5+ (including .NET Core versions). Consider using Nullable reference types instead.

System.Diagnostics.Contracts.Contract 這個型別到 .NET 10 都還在 BCL 裡,但 ccrewrite 跟 cccheck 沒有跟上。API 文件自己也標了:沒有 binary rewriter,Contract.Ensures 這種契約「will not throw exceptions during run time if a contract is violated」,等於只剩註解。GitHub 上的 microsoft/CodeContracts repo 在 2023 年 7 月 15 日封存

問題出在 docs 那句 Consider using Nullable reference types instead。nullable reference types(C# 8, 2019)加上 [NotNullWhen][MemberNotNull] 這些 attribute,只處理 null 這一個維度,x > 0、回傳值的範圍、物件不變量,一個都表達不了。它補的是 Code Contracts 最常被用到的那個角落,不是取代整套 design by contract

真正在現代 C# 裡做前置條件檢查的,是 runtime 的 guard clause:ArgumentNullException.ThrowIfNull(x)(.NET 6)、ArgumentOutOfRangeException.ThrowIfNegative(n)(.NET 8)這類。寫起來比 Contract.Requires 囉唆,也沒有靜態驗證,但至少不用裝一整套工具鏈,編譯期證明契約那一塊,C# 就是沒有了

Kotlin 的 contract 也不是 design by contract

講到這裡要拉回來澄清一件事:kotlin.contracts 跟 Code Contracts 不是同一個層級的東西,它反而跟 C# 的 nullable attribute 是同一類

看前面「contract 的限制」那節就知道:implies 右邊只能是 valuevalue != nullvalue is Type 這種簡單布林表達式,寫不出 value > 0;compiler 也不驗證你有沒有說謊,更不會產生任何 runtime 檢查。它做的事情只有一件,餵資訊給 compiler 的 flow analysis,換 smart cast 跟 definite assignment

想在 Kotlin 做真正的前置條件檢查,用的是 requirecheck 這些會實際拋例外的函式,跟 C# 的 guard clause 一樣。contract 只是讓這些函式呼叫完之後,compiler 順便知道條件成立了

三者的位置可以這樣排

機制 能表達什麼 誰檢查
Code Contracts 任意布林、後置條件、物件不變量 ccrewrite(runtime) + cccheck(編譯期證明)
C# nullable attribute 只有 null-state compiler 的 flow analysis
kotlin.contracts 簡單布林(null、is、布林變數) compiler 的 flow analysis,信任但不驗證

第一列沒有接班人。C# 和 Kotlin 都停在第二、三列,靠 compiler 分析換來的那點聰明,加上 runtime 的 guard clause

C# 的 nullable attribute 是穩定 API,Kotlin 的 contract 還掛著 @ExperimentalContracts,實務上 Kotlin contract 已穩定多年(stdlib 自己大量使用),但官方還沒拍板最終 API 形態

第九部分回顧

第九部分(day 32 到 day 35)是「進階語法篇」。我們從 scope function 出發,一路把 Kotlin Collection API 的進階語法補齊:day 32 手刻五大 scope function,day 33 做自訂運算子與中綴函式,day 34 處理泛型型變與 reified,day 35 用 contract 讓 compiler 更聰明。這四篇的共同點是,它們都不是「某個 Collection 操作」,而是讓你有能力自己設計出 stdlib 那種好用 API 的底層工具

本部分 API 速查

函式 篇號 一句話用途
myLet / myRun day 32 scope function,把物件傳進 Lambda 做運算後回傳結果
myWith / myApply / myAlso day 32 scope function,分別處理 receiver 傳入、設定後回傳自己、副作用後回傳自己
MyIntList(operator fun) day 33 get / invoke / plus / contains 掛上 []()+in
myTo / myRepeat day 33 自訂 infix 中綴函式,配對與字串重複
myFilterIsInstance day 34 reified 在 runtime 篩出特定型別的元素
myLetWithContract / myRunWithContract day 35 加上 callsInPlace 的 scope function,讓 Lambda 內能初始化 val
myRequire / myCheck / myCheckNotNull day 35 returns() implies 撐起 smart cast 的前置條件檢查

小結

contract 解決的問題是:compiler 不知道 Lambda 的呼叫時機和次數,也不知道函式回傳代表什麼條件成立。callsInPlace 解第一個問題,讓 scope function 裡面可以初始化 valreturns implies 解第二個問題,讓 requirecheckcheckNotNull 之後可以做 smart cast。目前還是實驗性 API,但 stdlib 自己大量使用,實務上很穩定

下一篇終於是全系列最終回

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 34:泛型進階 — 型變、星號投影與 reified
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言